為什麼內容一樣的物件,放進 Set 卻不會被去重?先弄懂 Set 判斷「一樣」的規則,再用 ES2025 的集合運算,一行比出交集資料間少了什麼、篩選條件差在哪。
前置知識:會使用 Set、理解 primitive 與 object reference 的基本差異。
學習路線:資料模型段落第三篇。Day 20 認識 Object、Map、Set 各自的規則 → Day 21 用 Object.groupBy() 把資料分組 → 今天:用 Set 找出唯一值,並比對兩組資料 。
標籤:ES2025 Set SameValueZero Set Methods Set-like

NaN 放得進去也找得回來,0 和 -0 又為什麼算同一個union()、intersection()、difference()、symmetricDifference() 合併資料、找共同項、找缺少項、找出差異isSubsetOf() 這類方法,直接回答「權限夠不夠」這種是非題intersection() 結果順序的陷阱很多人第一次認識 Set,是從「陣列去重」開始:
const numbers = new Set([1, 1]);
console.log(numbers.size);
// 1
看起來很合理。但把數字換成物件,結果卻不一樣:
const users = new Set([
{ id: 1, name: "Rafael" },
{ id: 1, name: "Rafael" },
]);
console.log(users.size);
// 2
明明內容完全一樣,為什麼 Set 沒有把第二個物件去掉?
答案不在「Set 能不能去重」,而在一個更根本的問題:
JavaScript 怎麼判斷兩個 value 是不是同一個?
這篇先沿著 TC39 規格找到答案,再看 ES2025 新增的 union()、intersection()、difference()。理解底層規則後,這些集合 API 就不需要死背。
ECMAScript 規格不是 engine 的原始碼。它不規定 V8 要用什麼資料結構存 Set,只規定「從程式外面看得到的行為」。
為了描述這些行為,規格用一個內部欄位 [[SetData]] 代表 Set 裡的資料(雙中括號表示 JavaScript 讀不到它)。可以把它想成一份依加入順序排列的資料:
const tags = new Set(["javascript", "css", "html"]);
// [[SetData]] ≈ ["javascript", "css", "html"]
console.log([...tags]);
// ["javascript", "css", "html"]:依加入順序走訪
這份名冊只有三條規則:
| 操作 | 規格怎麼做 | 白話 |
|---|---|---|
add(value) |
資料裡沒有相同的值,才 append 到尾端 | 新元素永遠排在最後 |
delete(value) |
把那一格換成 empty,其他元素不搬動 |
只標記刪除,資料整體不會縮短 |
走訪(for...of、forEach()) |
用 index 從頭往後讀,遇到 empty 就跳過;每一輪都重新確認資料長度 |
只記得「讀到第幾格」 |
empty 只是規格的描述手法。實際的 engine 可以真的把元素移除,只要對外行為一樣即可。
empty?因為刪除可能發生在走訪途中:
const tags = new Set(["javascript", "css", "html"]);
for (const tag of tags) {
console.log(tag);
if (tag === "css") {
tags.delete("javascript"); // 刪掉已經走過的元素
}
}
// javascript
// css
// html
走到 "css" 時,第 0、1 格已經讀完,下一步要讀第 2 格:
留下 empty:[empty, "css", "html"] → 第 2 格是 "html",照常走到
直接移除: ["css", "html"] → 第 2 格已超出長度,"html" 被跳過
保留位置,就是為了讓走訪中的 index 不會因為刪除而錯位。
實務上仍建議避免邊走訪邊修改同一個 Set;但就算遇到了,結果也是規格定義好的,不是 engine 各自決定的巧合。
Set 準備加入一個新元素時,會先檢查資料是否已有相同的 value,這個「相同」用的是規格中的 SameValueZero。今天往下多挖一層:它和 ===、Object.is() 到底差在哪?名字裡的「Zero」又是什麼意思...上次因為篇幅關係簡略帶過,又遇到了稍微來看一下:
NaN 與 -0 從哪來?先認識 IEEE 754JavaScript 的 Number 不是自己發明的格式,而是採用 IEEE 754 雙精度浮點數。這是 1985 年訂定的國際標準,C、Java、Python 的浮點數也都遵守它。所以下面這些「怪現象」其實不是 JavaScript 獨有的:
0.1 + 0.2; // 0.30000000000000004
NaN === NaN; // false
0 === -0; // true
NaN(Not-a-Number) 是 IEEE 754 用來表示「這次運算沒有有效結果」的值:
0 / 0; // NaN
Number("hello"); // NaN
Math.sqrt(-1); // NaN
標準規定 NaN 和任何值比較都不相等,包括它自己。原因很直覺:0 / 0 和 Math.sqrt(-1) 都是「算不出來」,但不能說它們是同一個答案。
早期 JavaScript 還沒有 Number.isNaN(),於是有人利用這個特性,寫出「只有 NaN 不等於自己」的判斷:
const isReallyNaN = (value) => value !== value;
isReallyNaN(NaN); // true
另外要小心全域的 isNaN():它會先把參數轉成數字,所以字串也可能被誤判。現代寫法請用不轉型的 Number.isNaN():
isNaN("hello"); // true:先轉成 Number("hello"),得到 NaN
Number.isNaN("hello"); // false:不轉型,只認真正的 NaN
isNaN("hello"); // true:先轉成 Number("hello"),得到 NaN
Number.isNaN("hello"); // false:不轉型,只認真正的 NaN
-0 則來自 IEEE 754 的儲存方式:數字用一個位元記錄正負號,所以 0 也有正負兩種。它保留了「從哪個方向接近 0」的資訊,平常用 === 看不出差別,但在某些運算裡會現身:
1 / 0; // Infinity
1 / -0; // -Infinity
Math.round(-0.4); // -0
0 * -5; // -0
String(-0); // "0":轉成字串後完全看不出來
Object.is(-0, 0); // false:只有 Object.is() 分得出來
NaN === NaN 為 false 的行為適合數值比較,卻不適合集合。若 Set 完全使用 ===,就會發生「放得進去,卻找不回來」的問題:
// 假想 Set 完全使用 === 的結果:
// set.add(NaN);
// set.has(NaN); 會是 false
===、SameValueZero、Object.is()規格裡其實有三套常見的相等判斷,差別只落在 NaN 與 -0 這兩個特例:
| 判斷方式 | NaN 與 NaN |
0 與 -0 |
誰在使用 |
|---|---|---|---|
===(IsStrictlyEqual) |
不同 | 相同 | ===、indexOf() |
SameValueZero |
相同 | 相同 | Set、Map、includes() |
SameValue |
相同 | 不同 | Object.is() |
表格沒有列出 ==(寬鬆相等),因為它比較前會先轉型,例如 0 == "" 是 true;Set、Map 與 includes() 都不會使用它。
同樣是在陣列裡找 NaN,用的規則不同,結果就不同:
[NaN].indexOf(NaN); // -1:=== 認不出 NaN
[NaN].includes(NaN); // true:SameValueZero 認得出
Object.is(0, -0); // false:SameValue 會區分正負零
名字裡的「Zero」就是相對於 SameValue 而言:SameValueZero 幾乎等於 SameValue,只差在把 0 與 -0 視為同一個零。也可以反過來記:它是「修好 NaN 的 ===」。
const values = new Set([NaN, NaN, 0, -0]);
console.log(values.size);
// 2
console.log(values.has(NaN));
// true
還有一個容易忽略的細節:Set 在加入元素時,規格會先把 -0 轉成 +0 再存。所以就算只放入 -0,取出來的也是 0:
const [zero] = new Set([-0]);
Object.is(zero, -0); // false
Object.is(zero, 0); // true
一般開發不需要手動呼叫這些演算法;重點是知道 Set 不是把資料交給
===後直接去重,而是依 >SameValueZero判斷元素是否已存在。
上一節的三種規則,差異只出現在 NaN 與 -0 這類 primitive。碰到物件、陣列與 function 時,它們的判斷方式完全相同:
碰到物件、陣列與 function 時,它們的判斷方式完全相同:
只看 reference identity,也就是兩個值是不是指向同一個物件,而不是內容看起來是否相同。可以把 reference 想成物件在記憶體裡的「地址」:變數存的是地址,比較時也只比地址。
const a = { id: 1 };
({ id: 1 }) === ({ id: 1 }); // false
Object.is({ id: 1 }, { id: 1 }); // false
new Set([{ id: 1 }]).has({ id: 1 }); // false
Object.is(a, a); // true:同一個 reference
換句話說,Object.is() 並不是「比較物件內容」的工具;JavaScript 沒有內建的 deep equality。Set 用的 SameValueZero 也一樣。
可以把它想成兩張內容完全一樣的表單。文字相同不代表它們是同一張紙;在記憶體裡建立兩個物件,也會得到兩個不同 reference。
const first = { id: 1, name: "Rafael" };
const second = { id: 1, name: "Rafael" };
const users = new Set([first, second]);
console.log(users.size);
// 2
console.log(users.has({ id: 1, name: "Rafael" }));
// false
console.log(users.has(first));
// true
first 與 second 內容相同,但不是同一個物件;而臨時建立的 { id: 1, name: "Rafael" } 又是第三個新物件。Set 不會逐欄位做 deep equality。
到這裡,Set 的基本規則已經齊了:
primitive value
→ 依 SameValueZero 比較
object / array / function
→ 比較是不是同一個 reference
知道 Set 怎麼認出相同元素後,下一個問題是:兩組 Set 要怎麼比較?
假設留言功能需要 read、comment 權限;使用者目前只有 read、export:
const requiredPermissions = new Set(["read", "comment"]);
const userPermissions = new Set(["read", "export"]);
要找出「功能需要,但使用者沒有」的權限,以前常用展開、filter() 與 has() 組裝:
const missingPermissions = new Set(
[...requiredPermissions].filter(
(permission) => !userPermissions.has(permission),
),
);
console.log([...missingPermissions]);
// ["comment"]
這段程式沒有錯,但真正想說的其實是:
從必要權限中,扣掉使用者已有的權限
專業名詞稱作「差集」,過去 JavaScript Set 沒有原生方法,所以我們只能自己把這個概念翻譯成迴圈。
ES2025 將常見集合運算正式加入 Set.prototype。先準備兩組標籤:
const selectedTags = new Set(["javascript", "css"]);
const recommendedTags = new Set(["css", "accessibility"]);
接著不要急著背「聯集、交集」這些名詞,先看每個方法回答什麼問題。
union():兩邊出現過的全部都要const allTags = selectedTags.union(recommendedTags);
console.log([...allTags]);
// ["javascript", "css", "accessibility"]
"css" 兩邊都有,但 Set 不會重複保存,所以結果只出現一次。這裡使用的「相同」仍是前面介紹的 SameValueZero。
intersection():只留下兩邊都有的const commonTags = selectedTags.intersection(recommendedTags);
console.log([...commonTags]);
// ["css"]
例如兩套篩選器只想顯示共同支援的選項,就很適合用交集。
difference():我有,但你沒有的const onlySelected = selectedTags.difference(recommendedTags);
console.log([...onlySelected]);
// ["javascript"]
順序很重要:
selectedTags.difference(recommendedTags);
// selectedTags 有、recommendedTags 沒有的
recommendedTags.difference(selectedTags);
// recommendedTags 有、selectedTags 沒有的
這也是前面權限案例真正需要的方法。
symmetricDifference():只留下兩邊不一致的const differentTags = selectedTags.symmetricDifference(recommendedTags);
console.log([...differentTags]);
// ["javascript", "accessibility"]
兩邊都有的 "css" 被排除,只留下只有其中一邊擁有的項目。這很適合用來比較「儲存前」與「儲存後」有哪些設定改變。
可以用四句話記住:
| 方法 | 白話意思 |
|---|---|
union() |
兩邊全部都要 |
intersection() |
只要兩邊共同的 |
difference() |
我有、你沒有的 |
symmetricDifference() |
只要兩邊不一樣的 |
這四個方法會建立新的 Set,不會修改原本資料:
console.log([...selectedTags]);
// ["javascript", "css"]
Set methods 是 ES2025 才進入標準的 API,需要 Chrome/Edge 122、Firefox 127、Safari 17、Node.js 22 以上的版本。
回到權限案例:
const requiredPermissions = new Set(["read", "comment"]);
const userPermissions = new Set(["read", "export"]);
const missingPermissions = requiredPermissions.difference(userPermissions);
console.log([...missingPermissions]);
// ["comment"]
difference() 適合列出缺少哪些權限。但有時畫面只需要一個 boolean,決定按鈕能不能顯示。
以前要回答「使用者的權限夠不夠」,通常會這樣寫:
const canUseCommentFeature = [...requiredPermissions].every((permission) =>
userPermissions.has(permission),
);
// false
ES2025 也提供三個關係判斷方法,讓這個問題可以直接問出口:
const canUseCommentFeature = requiredPermissions.isSubsetOf(userPermissions);
// false:使用者尚未具備所有必要權限
userPermissions.isSupersetOf(requiredPermissions);
// false:同一件事,從相反方向問
userPermissions.isDisjointFrom(new Set(["admin"]));
// true:使用者沒有任何 admin 權限
先不要背 subset、superset 的中文名稱,直接讀它問的問題:
| 方法 | 白話問題 |
|---|---|
A.isSubsetOf(B) |
A 的每個元素,B 都有嗎? |
A.isSupersetOf(B) |
B 的每個元素,A 都有嗎? |
A.isDisjointFrom(B) |
A 與 B 完全沒有共同元素嗎? |
於是權限判斷不再需要藏在 every() 與 has() 裡,而能直接表達意圖:
if (canUseCommentFeature) {
showCommentButton();
}
什麼是 set-like?為什麼不能直接傳 Array?
前面的例子都在比較兩個真正的 Set:
selectedTags.union(recommendedTags);
但 ES2025 規格沒有要求右邊的 other 一定是 new Set()。它可以是 set-like object:不是原生 Set,卻具備一個集合進行比較時需要的能力。
TC39 規格會透過 GetSetRecord 讀取三個成員:
size → 這組資料有幾個元素
has(value) → 某個元素是否存在
keys() → 逐一提供集合裡的元素
只要右邊的物件提供這三種能力,就能參與 union()、intersection()、difference() 與其他 Set 比較。
有趣的是,Map 本身就是 set-like:
const selectedPermissions = new Set(["read"]);
const permissionConfig = new Map([
["read", { label: "讀取" }],
["comment", { label: "留言" }],
]);
const allPermissions = selectedPermissions.union(permissionConfig);
console.log([...allPermissions]);
// ["read", "comment"]
因為 Map 也有 size、has(key) 與 keys()。在這個例子裡,Map 的 key 被視為集合元素,value 不參與比較。
以 union() 來說,概念流程是:先複製左側 Set,再逐一讀取 permissionConfig.keys()。
左側先放入 "read"
讀到 Map key "read"
→ 已存在,不重複加入
讀到 Map key "comment"
→ 尚未存在,加入結果
因此結果只有權限名稱:
console.log([...allPermissions]);
// ["read", "comment"]
Map 裡的 { label: "讀取" }、{ label: "留言" } 是 value,不會進入 union 結果。
先說結論:不需要為了效能刻意調整左右順序。
想像你要找出兩個班級裡,同時參加籃球社的同學。聰明的做法是拿人數少的那份名單,一個一個去問人數多的那班「有沒有這個人」;要問幾次,只跟人數少的那份有關。
intersection() 就是這樣做的:它會先比較兩邊的 size,自動挑比較小的那邊來走訪,再用另一邊的 has() 檢查。
const commonPermissions = selectedPermissions.intersection(permissionConfig);
// 左邊 1 個、右邊 2 個 → 走訪左邊,只要問一次 permissionConfig.has("read")
console.log([...commonPermissions]);
// ["read"]
所以 A.intersection(B) 和 B.intersection(A) 的工作量差不多,規格已經幫你挑好了。difference() 等方法也有類似的設計。這也說明了 set-like 為什麼要同時提供三個成員:size 用來比大小、keys() 用來走訪、has() 用來檢查。
左右順序真正會影響的,是下面這三件事:
| 左右對調會影響什麼 | 說明 |
|---|---|
| 答案的意思 | A.difference(B) 是「A 有、B 沒有」,對調後問的是另一個問題 |
| 誰必須是真正的 Set | 左邊一定要是 Set;右邊只要是 set-like 就好,例如 Map |
intersection() 結果的順序 |
見下面的陷阱 |
intersection() 的結果順序不一定跟著左邊自動挑方向雖然省事,卻有一個副作用:結果的順序會跟著「被走訪的那一邊」。
這裡的「走訪」,就是把一邊的元素照加入順序一個一個拿出來檢查。intersection() 的完整流程是:
size,挑比較小的那一邊來走訪(一樣大就挑左邊)has() 有沒有這個元素;有,就放進結果結果 Set 依「放進去的先後」排列,所以最後的順序,就是被走訪那一邊的順序。來看一個例子:
const allSteps = new Set([1, 2, 3, 4]);
const pickedSteps = new Set([4, 3]);
console.log([...allSteps.intersection(pickedSteps)]);
// [4, 3]
直覺上會以為結果照左邊 allSteps 的順序排成 [3, 4]。但左邊有 4 個、右邊只有 2 個,所以走訪的是右邊的 pickedSteps:
| 步驟 | 從 pickedSteps 拿出 |
問 allSteps.has() |
結果 |
|---|---|---|---|
| 1 | 4 |
true |
[4] |
| 2 | 3 |
true |
[4, 3] |
換幾種寫法比較,就能看出規則:
| 寫法 | 兩邊大小 | 走訪哪一邊 | 結果 |
|---|---|---|---|
allSteps.intersection(pickedSteps) |
4 比 2 | 右邊 pickedSteps |
[4, 3] |
pickedSteps.intersection(allSteps) |
2 比 4 | 左邊 pickedSteps |
[4, 3] |
new Set([1, 2]).intersection(new Set([2, 1])) |
一樣大 | 左邊 | [1, 2] |
一句話記住:intersection() 的結果順序跟著比較小的那一邊;一樣大時才跟著左邊。
如果畫面依賴特定順序,例如步驟條或排序過的選項,就不要依賴 intersection() 的迭代順序,改從來源資料重新篩選:
const orderedSteps = [...allSteps].filter((step) => pickedSteps.has(step));
// [3, 4]
selectedPermissions.union(["comment", "export"]);
// TypeError
Array 可以被 for...of 迭代,卻沒有 Set methods 要求的介面:
Set Array
size length
has(value) 沒有;includes() 名稱與介面不同
keys() 產生元素 keys() 產生索引 0、1、2……
元素不重複 可以包含重複元素
這也說明 iterable 和 set-like 是兩種不同能力:
iterable
→ 我可以一筆一筆把資料交給你
set-like
→ 我還能告訴你總數,以及某個值是否存在
若只接受任意 iterable,就沒有 size 與 has() 這些集合能力;Array 也可能包含重複資料,和 Set 的唯一值語意不同。
因此要讓 Array 參與比較,先明確轉成 Set:
const extraPermissions = ["comment", "export", "comment"];
const allPermissions = selectedPermissions.union(
new Set(extraPermissions),
);
console.log([...allPermissions]);
// ["read", "comment", "export"]
可以把 new Set(array) 看成一次明確的資料正規化:去除重複值,並補上 size、has()、keys() 這組集合介面。
回到文章開頭,Set 沒有把兩個 { id: 1 } 合併,不是它失效,而是我們沒有告訴它「唯一性來自 id」。
Set 很適合直接保存:
id、權限名稱、標籤、功能開關、已選選項
但若唯一性實際上由物件中的欄位決定,先抽出欄位:
const uniqueProductIds = new Set(
products.map((product) => product.id),
);
若你還需要依 id 找回完整物件,則 Map 通常更貼近需求:
const productById = new Map(
products.map((product) => [product.id, product]),
);
Set 負責判斷 value 是否相同,不負責替應用程式定義「哪個欄位代表同一筆資料」。先釐清資料模型,才不會因為「Set 可以去重」就把所有物件都丟進去。
我們從「兩個內容一樣的物件為什麼沒有被去重」出發,沿著規格得到答案:Set 的集合運算不是另一套神奇 API,而是建立在既有的相等規則上。
| 觀念 | 一句話記住 |
|---|---|
[[SetData]] |
規格描述 Set 的模型:依加入順序保存,刪除只標記 empty,走訪途中修改也有明確結果 |
| SameValueZero | 判斷 primitive 是否相同:NaN 等於 NaN,0 等於 -0 |
| reference identity | 物件只看是不是同一個 reference;內容一樣也算兩筆,想依 id 去重要先抽出 id |
| set-like | 集合運算的另一邊要有 size、has()、keys():Map 可以直接用,Array 要先轉成 Set |
理解這些規則後,ES2025 的 API 就很好選,直接看你想回答什麼問題:
| 想回答的問題 | 方法 | 回傳 |
|---|---|---|
| 兩組合起來有哪些? | A.union(B) |
新的 Set |
| 兩組共同有哪些? | A.intersection(B) |
新的 Set(順序可能跟著較小的一邊) |
| A 有、B 沒有的是哪些? | A.difference(B) |
新的 Set |
| 兩組不一樣的是哪些? | A.symmetricDifference(B) |
新的 Set |
| A 的每一項,B 都有嗎? | A.isSubsetOf(B) |
boolean |
| B 的每一項,A 都有嗎? | A.isSupersetOf(B) |
boolean |
| A 和 B 完全沒有共同項嗎? | A.isDisjointFrom(B) |
boolean |